<?php

namespace Cephalopod\SSL;

use Cephalopod\HttpTube;
use Cephalopod\LogRecorder;
use Exception;

class Client implements MainLogic
{
    private string $listenIpAddress = '127.0.0.1';

    private int $listenPort = 9001;

    private string $serverIpAddress = '127.0.0.1';

    private int $serverListenPort = 9002;

    private string $serverPublicKey = '';

    private string $clientPrivateKey = '';

    private int $serverId;

    private array $idContextMap = []; // Key is id, value is a Context object

    public function __construct()
    {
        IOEvent::bind($this);
    }

    public function setListenIpAddress(string $listenIpAddress)
    {
        $this->listenIpAddress = $listenIpAddress;
    }

    public function setListenPort(int $listenPort)
    {
        $this->listenPort = $listenPort;
    }

    public function setServerIpAddress(string $serverIpAddress)
    {
        $this->serverIpAddress = $serverIpAddress;
    }

    public function setServerListenPort(int $serverListenPort)
    {
        $this->serverListenPort = $serverListenPort;
    }

    public function setServerPublicKey(string $filePath)
    {
        $this->serverPublicKey = file_get_contents($filePath);
    }

    public function setClientPrivateKey(string $filePath)
    {
        $this->clientPrivateKey = file_get_contents($filePath);
    }

    public function run(): bool
    {
        while (true) {
            try {
                $this->connectToServer();
                IOEvent::run($this->listenIpAddress, $this->listenPort);
            } catch (Exception $e) {
                LogRecorder::logError('An exception occurred: "' . $e->getMessage() . '"');
                LogRecorder::logInfo('All network connections are lost: total=' . count($this->idContextMap, COUNT_NORMAL));
                $this->idContextMap = [];
            }
        }
        return true;
    }

    public function handleReadEvent(int $id, string &$readBuffer): int
    {
        if (!isset($this->idContextMap[$id])) {
            LogRecorder::logError("A readable event occurred on an unknown ID: $id. Close connection");
            IOEvent::closeExistsConnection($id);
            return 0;
        }
        $context = $this->idContextMap[$id];
        if ($context->isClientServerConnection) {
            $context->lastDataRecvTime = time();
            LogRecorder::logInfo("Download " . strlen($readBuffer) . " bytes from the server, ID: $id");
            if ($this->serverId != $id) {
                $tiis->logError("This ID may be an old server connection and will be closed: $id."
                    . " And current server ID is {$this->serverId}");
                IOEvent::closeExistsConnection($id);
                return 0;
            }
            $this->checkKeepAliveAndSendHeartBeatPackage($context);
            return $this->unpackServerData($readBuffer, $context);
        }
        if ($this->serverId != $context->dataSentToId) {
            LogRecorder::logError("ID: {$id}. Current server ID: {$this->serverId} != send to ID: {$context->dataSentToId}");
            IOEvent::closeExistsConnection($id);
            return 0;
        }
        $controlFlag = $context->isFirstMessage ? Protocol::CONTROL_FLAG_FIRST_DATA_YES : Protocol::CONTROL_FLAG_FIRST_DATA_NO;
        $sendToContext = $this->idContextMap[$context->dataSentToId];
        $totalDataSizeBefore = strlen($sendToContext->dataToBeWritten);
        $sendToContext->dataToBeWritten .= HttpTube::buildUploadPostData(
            Protocol::pack($id, $controlFlag, $readBuffer, fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->serverPublicKey)),
            $this->serverIpAddress,
            $this->serverListenPort
        );
        $totalDataSizeAfter = strlen($sendToContext->dataToBeWritten);
        $context->isFirstMessage = false;
        LogRecorder::logInfo("The data to be uploaded to the server +" . ($totalDataSizeAfter - $totalDataSizeBefore) . " bytes. From ID: $id");
        return PHP_INT_MAX;
    }

    public function handleWriteEvent(int $id, string &$writeBuffer)
    {
        if (!isset($this->idContextMap[$id])) {
            LogRecorder::logError("A writable event occurred on an unknown ID: $id. Close connection");
            IOEvent::closeExistsConnection($id);
            return;
        }
        $context = $this->idContextMap[$id];
        if ('' === $context->dataToBeWritten) {
            return;
        }
        $this->checkKeepAliveAndSendHeartBeatPackage($context);
        $writeBuffer = $context->dataToBeWritten;
        LogRecorder::logInfo("Send data " . strlen($context->dataToBeWritten) . " bytes to ID: $id");
        $context->dataToBeWritten = '';
    }

    public function handleOpenEvent(int $id)
    {
        if (isset($this->idContextMap[$id])) {
            return;
        }
        LogRecorder::logInfo("A new connection is established, ID: $id");
        $context = new Context($this->clientPrivateKey);
        $context->contextBelongsToId = $id;
        $context->dataSentToId = $this->serverId;
        $this->idContextMap[$id] = $context;
    }

    public function handleCloseEvent(int $id)
    {
        if (isset($this->idContextMap[$id])) {
            $context = $this->idContextMap[$id];
            if ($context->isClientServerConnection && $id == $this->serverId) {
                throw new Exception("Unable to connect to server, ID: $id");
            } else {
                LogRecorder::logInfo("Disconnected, ID: $id");
            }
            unset($this->idContextMap[$id]);
            return;
        }
        LogRecorder::logInfo("Connection closed, ID: $id");
    }

    private function connectToServer()
    {
        LogRecorder::logInfo("Connect to server {$this->serverIpAddress}:{$this->serverListenPort}");
        $id = IOEvent::createNewConnection($this->serverIpAddress, $this->serverListenPort);
        $this->serverId = $id;
        $context = new Context($this->clientPrivateKey);
        $context->isClientServerConnection = true;
        $context->contextBelongsToId = $id;
        $context->lastDataRecvTime = time();
        $this->idContextMap[$id] = $context;
        LogRecorder::logInfo("Server ID: $id");
    }

    private function unpackServerData(string &$serverData, Context $context): int
    {
        $idDataSizeMap = [];
        $idIgnore = [];
        $nowTime = time();
        foreach ($context->unpack($serverData, false) as list($id, $controlFlag, $data)) {
            if (Protocol::CONTROL_FLAG_HEARTBEAT == $controlFlag) {
                LogRecorder::logInfo('Heartbeat packet received, ID: ' . $context->contextBelongsToId);
                $context->lastHeartBeatRecvTime = $nowTime;
            } elseif (isset($this->idContextMap[$id])) {
                $context = $this->idContextMap[$id];
                $context->dataToBeWritten .= $data;
                $context->lastDataRecvTime = $nowTime;
                $idDataSizeMap[$id] = isset($idDataSizeMap[$id]) ? $idDataSizeMap[$id] + strlen($data) : strlen($data);
            } else {
                $idIgnore[$id] = null;
            }
        }
        foreach ($idDataSizeMap as $id => $size) {
            LogRecorder::logInfo("Will send data $size bytes to ID: $id");
        }
        !empty($idIgnore) && LogRecorder::logInfo("Ignore not exists IDs: " . implode(',', array_keys($idIgnore)));
        return PHP_INT_MAX;
    }

    private function checkKeepAliveAndSendHeartBeatPackage(Context $context)
    {
        if ($context->isClientServerConnection) {
            $nowTime = time();
            $failTime = Protocol::HEARTBEAT_FAIL;
            if ($nowTime - $context->lastHeartBeatRecvTime > $failTime && $nowTime - $context->lastDataRecvTime > $failTime) {
                throw new Exception("Connection closed due to keep-alive timeout. No data communication has been received for {$failTime} seconds. ID: {$context->contextBelongsToId}");
            }
            if ($nowTime - $context->lastHeartBeatSendTime > Protocol::HEARTBEAT_DELAY) {
                $context->dataToBeWritten .= HttpTube::buildUploadPostData(
                    Protocol::pack(mt_rand(0, PHP_INT_MAX), Protocol::CONTROL_FLAG_HEARTBEAT, (string)mt_rand(0, PHP_INT_MAX), fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->serverPublicKey)),
                    $this->serverIpAddress,
                    $this->serverListenPort
                );
                LogRecorder::logInfo('Write heartbeat packet to server connection, ID: ' . $context->contextBelongsToId);
                $context->lastHeartBeatSendTime = $nowTime;
            }
        }
    }
}
